Conversation
This stack of pull requests is managed by Graphite. Learn more about stacking. |
c65984a to
bf92dc4
Compare
96d95ba to
d8b0d4a
Compare
6e09feb to
5c1ddf1
Compare
d8b0d4a to
f042d96
Compare
5c1ddf1 to
06dee38
Compare
06dee38 to
40bf3e5
Compare
8cc6cc8 to
cc438ef
Compare
91e9fca to
a6d5ab9
Compare
19ce420 to
5b58638
Compare
| }) | ||
| // Try the extension's build output first (for compiled bundles), then fall back | ||
| // to the extension's source directory (for static assets like tools, instructions). | ||
| const buildPath = joinPath(extension.directory, extension.outputRelativePath) |
There was a problem hiding this comment.
@isaacroldan this fixes the issue mentioned here. There was a regression where we were getting updates even when the assets fail to build because we were serving them from the .dev-bundle which gets freshly created even when the build fails but it should be using the local assets. When the build fails the old local assets should not be deleted and we should not get a new update event in the websocket
There was a problem hiding this comment.
Nope, never mind. I double checked and it still sends an update event, something else is going on
| // destination instead, it would pick up files accumulated from previous builds | ||
| // that may no longer exist in the source, inflating the file count and producing | ||
| // stale entries in the manifest's pathMap. | ||
| const sourceFiles = await glob(['**/*'], {cwd: fullPath, absolute: false}) |
There was a problem hiding this comment.
Previously this was looking at copied files which broke when including assets for the adminspecificaiton after I fixed the issue where files were getting a new name on every build in dev even when there was no collision.
Fixed this to match copy-by-pattern.ts by looking at the source files, not the files in the destination folder.
| // destination instead, it would pick up files accumulated from previous builds | ||
| // that may no longer exist in the source, inflating the file count and producing | ||
| // stale entries in the manifest. | ||
| const sourceFiles = await glob(['**/*'], {cwd: sourcePath, absolute: false}) |
There was a problem hiding this comment.
Same here, fixed this to match copy-by-pattern.ts by looking at the source files, not the files in the destination folder.
5b58638 to
8549387
Compare
17e3ff5 to
c2c3f95
Compare
34150b4 to
5c1736d
Compare
Include built assets in the manifest.json Allow serving static assets from the extensions directory
8cfce51 to
a65c60d
Compare
…ore copying over to the bundle Remove copy_static_assets client step completely. It was doing nothing for specifications that were not ui_extension. ui_extension now has its own steps for including static assets
a65c60d to
de5e9d9
Compare
| readonly type: 'bundle_ui' | ||
| readonly generatesAssetsManifest?: boolean | ||
| readonly config?: Record<string, never> | ||
| } |
There was a problem hiding this comment.
Shouldn't generatesAssetsManifest be part of the config ?
| * writes built asset entries (from build_manifest) into manifest.json so | ||
| * downstream steps can merge on top. | ||
| */ | ||
| export async function executeBundleUIStep(step: LifecycleStep, context: BuildContext): Promise<void> { |
There was a problem hiding this comment.
you can safely change this from LifecycleStep to BundleUIStep
So you don't need to check at runtime for the config fields.
| * Reads the existing manifest if present and deep merges the new entries. | ||
| * This allows multiple build steps to contribute to the same manifest. | ||
| */ | ||
| export async function mergeManifestEntries(context: BuildContext, entries: {[key: string]: unknown}): Promise<void> { |
There was a problem hiding this comment.
I think this should have a more explicit name?
The name suggests an in memory merge, but it is updating the existing file (or creating it if doesn't exist).
createOrUpdateManifestFilemaybe?
isaacroldan
left a comment
There was a problem hiding this comment.
Review assisted by pair-review
There was a problem hiding this comment.
(Ref Line 96) 🐛 Bug: All three analysis passes flagged this: buildUIExtension now writes the main bundle to localOutputPath (the extension's own directory), but secondary assets at line 96 still write to dirname(extension.outputPath). When called through buildForBundle (which both dev and deploy use), extension.outputPath is mutated to the bundle directory. This creates a split:
- Main bundle → written to local dir
- should_render → written to bundle dir
executeBundleUIStepcopies local dir → bundle dir (only main transfers)- Dev middleware checks local dir then source dir — should_render is in neither
This is entangled with the middleware change at middlewares.ts:83, which no longer falls back to the bundle directory. Fixing this line so all assets go to the local directory would make the middleware change correct. Without this fix, secondary compiled assets will 404 from the dev server.
Suggestion: Build secondary assets to the local directory, consistent with main:
| outputPath: joinPath(dirname(localOutputPath), asset.outputFileName), |
| @@ -122,6 +126,7 @@ export async function buildUIExtension(extension: ExtensionInstance, options: Ex | |||
| const duration = Math.round(performance.now() - startTime) | |||
| const sizeInfo = await formatBundleSize(extension.outputPath) | |||
There was a problem hiding this comment.
🐛 Bug: All three analysis passes agree (confidence 0.90): formatBundleSize(extension.outputPath) reads from the bundle directory, but the file was just written to localOutputPath. The copy to the bundle directory happens later in executeBundleUIStep. On the first build, the file doesn't exist at the bundle path yet, so formatBundleSize silently returns empty string (it catches errors). On rebuilds, it reads the previous build's file — reporting stale size data. No crash, but the build success message loses bundle size info.
Suggestion:
| const sizeInfo = await formatBundleSize(extension.outputPath) | |
| const sizeInfo = await formatBundleSize(localOutputPath) |
|
|
||
| const config = context.extension.configuration as Record<string, unknown> | ||
| const extensionPoints = config.extension_points | ||
| if (!Array.isArray(extensionPoints) || !extensionPoints.every((ep) => typeof ep === 'object' && ep?.build_manifest)) |
There was a problem hiding this comment.
💡 Improvement: The check extensionPoints.every(ep => ep?.build_manifest) is all-or-nothing: if even one extension point doesn't have build_manifest, manifest generation is skipped for ALL extension points — including those that do have it. Currently this works because UIExtensionSchema always generates build_manifest for every extension point. But the guard would silently drop manifest entries if a future configuration omits build_manifest for some targets.
Suggestion: A filter would be more robust than an all-or-nothing check:
| if (!Array.isArray(extensionPoints) || !extensionPoints.every((ep) => typeof ep === 'object' && ep?.build_manifest)) | |
| if (!Array.isArray(extensionPoints)) return | |
| const pointsWithManifest = extensionPoints.filter( | |
| (ep): ep is ExtensionPointWithBuildManifest => typeof ep === 'object' && !!ep?.build_manifest, | |
| ) | |
| const entries = extractBuiltAssetEntries(pointsWithManifest) |
| let existing: {[key: string]: unknown} = {} | ||
| if (await fileExists(manifestPath)) { | ||
| const content = await readFile(manifestPath) | ||
| existing = JSON.parse(content) |
There was a problem hiding this comment.
🐛 Bug: Same pattern as readBundleManifest above but with the opposite failure mode: readFile + JSON.parse at line 132-133 run without any error handling. If manifest.json is corrupted from a previous failed build, JSON.parse throws an unhandled error that crashes the build. Unlike readBundleManifest which silently swallows errors, this one lets them propagate — blocking all subsequent builds until the developer manually deletes the corrupted file.
Suggestion: Wrap in a try/catch that falls back to an empty manifest:
| existing = JSON.parse(content) | |
| if (await fileExists(manifestPath)) { | |
| try { | |
| const content = await readFile(manifestPath) | |
| existing = JSON.parse(content) | |
| } catch { | |
| outputDebug(`Warning: could not parse existing manifest.json, starting fresh\n`, context.options.stdout) | |
| } | |
| } else { |
| extensionPoint as NewExtensionPointSchemaType & {build_manifest: BuildManifest}, | ||
| ...(await mapManifestAssetsToPayload( | ||
| manifestEntry, | ||
| extensionPoint as unknown as NewExtensionPointSchemaType, |
There was a problem hiding this comment.
🎨 Code Style: The cast extensionPoint as unknown as NewExtensionPointSchemaType uses as unknown to bypass TypeScript's type checking entirely. Since DevNewExtensionPointSchema extends Omit<NewExtensionPointSchemaType, 'intents'>, the core fields already match. The as unknown escape hatch suppresses potentially useful type errors that would catch real incompatibilities if these types diverge.
Suggestion: Consider widening mapManifestAssetsToPayload's parameter type to accept DevNewExtensionPointSchema directly, or use a direct cast without the unknown intermediary.
| const content = await readFile(manifestPath) | ||
| return JSON.parse(content) | ||
| // eslint-disable-next-line no-catch-all/no-catch-all | ||
| } catch { |
There was a problem hiding this comment.
💡 Improvement: The catch block catches all errors including JSON.parse failures from corrupted or partially-written manifest.json files. A readFile ENOENT (file doesn't exist) should return null — that's expected when no manifest has been generated yet. But a SyntaxError from JSON.parse means the file exists but is corrupted (e.g., partial write during a concurrent rebuild race), and silently returning null hides that problem. The developer would see missing assets with no explanation of why.
Suggestion: Distinguish file-not-found from parse errors:
| } catch { | |
| try { | |
| const manifestPath = joinPath(buildDirectory, 'manifest.json') | |
| const content = await readFile(manifestPath) | |
| return JSON.parse(content) | |
| // eslint-disable-next-line no-catch-all/no-catch-all | |
| } catch (error: unknown) { | |
| if (error instanceof SyntaxError) { | |
| throw new Error(`Invalid manifest.json in ${buildDirectory}: ${error.message}`) | |
| } | |
| return null |
| if (await fileExists(builtAssetPath)) { | ||
| return fileServerMiddleware(event, {filePath: builtAssetPath}) | ||
| } | ||
| return fileServerMiddleware(event, {filePath: joinPath(extension.directory, assetPath)}) |
There was a problem hiding this comment.
🔒 Security: The new middleware fallback serves files from extension.directory (the developer's source) using assetPath from the router. A crafted request like /extensions/{id}/assets/../../.env could read arbitrary files from the project directory. getAppAssetsMiddleware in the same file (lines 144-147) already has proper path traversal validation using resolvePath and startsWith. The inconsistency is worth addressing — the previous middleware only served from the build output temp directory, which had narrower scope. Now that the source directory is exposed, the same protection pattern should apply. The blast radius is limited to the developer's own machine (local dev server), but it's a gap compared to the established pattern.
Suggestion: Add path traversal validation matching the existing pattern from getAppAssetsMiddleware:
| return fileServerMiddleware(event, {filePath: joinPath(extension.directory, assetPath)}) | |
| // Try the build output directory first (for compiled assets like dist/handle.js), | |
| // then fall back to the extension's source directory (for static assets like tools, instructions). | |
| const builtAssetPath = joinPath(dirname(joinPath(extension.directory, extension.outputRelativePath)), assetPath) | |
| if (await fileExists(builtAssetPath)) { | |
| return fileServerMiddleware(event, {filePath: builtAssetPath}) | |
| } | |
| const resolvedDir = resolvePath(extension.directory) | |
| const resolvedAssetPath = resolvePath(extension.directory, assetPath) | |
| if (!resolvedAssetPath.startsWith(resolvedDir)) { | |
| return sendError(event, {statusCode: 403, statusMessage: 'Path traversal is not allowed'}) | |
| } | |
| return fileServerMiddleware(event, {filePath: resolvedAssetPath}) |

WHY are these changes introduced?
This change modernizes the UI extension asset handling system by replacing the legacy
build_manifestapproach with a new manifest.json-based system that supports additional asset types including intents.Related https://github.com/shop/issues-admin-extensibility/issues/2274
WHAT is this pull request doing?
intentsfield in extension point schemas with type, action, schema, name, and description propertiesIntentsto theAssetIdentifierenum for asset handlinginclude_assetsstep instead ofcopy_static_assets, with support for tools, instructions, and intent schemasHow to test your changes?
Make sure you have the proper betas applied (see canvas)
shopify app generate --template conditional_admin_actionpnpm shopify app devand verify the the UI extension has assets (main.js, should_render.js, tools schema url and instructions.md) that are fetchable through the Dev Server. You should be able to preview it in Admin Webshopify app generate --template admin_intent_linkpnpm shopify app devand verify the the Admin link extension has assets (main.js, tools schema url and instructions.md and intents schema URLs) fetchable through the Dev ServerMeasuring impact
How do we know this change was effective? Please choose one:
Checklist